Skip to content

Show opaque marker when deserializing custom classes and instances in o11y#809

Merged
TooTallNate merged 8 commits intomainfrom
01-19-show_opaque_marker_when_deserializing_custom_classes_and_instances_in_o11y
Jan 20, 2026
Merged

Show opaque marker when deserializing custom classes and instances in o11y#809
TooTallNate merged 8 commits intomainfrom
01-19-show_opaque_marker_when_deserializing_custom_classes_and_instances_in_o11y

Conversation

@TooTallNate
Copy link
Member

@TooTallNate TooTallNate commented Jan 19, 2026

Web example:

dark mode
Screenshot 2026-01-20 at 01 38 04

light mode
Screenshot 2026-01-20 at 01 38 09

CLI example:

Screenshot 2026-01-20 at 01 44 53

Added support for displaying custom class instances in the observability UI by showing opaque markers when deserializing custom classes.

What changed?

  • Added a CLASS_INSTANCE_REF_TYPE marker and related interfaces to represent serialized class instances that cannot be fully deserialized
  • Implemented helper functions to extract class names from class IDs and convert serialized instances to reference objects
  • Enhanced the stream print revivers to handle custom class instances and class references
  • Updated the attribute panel to recognize and display class instance references with a __class__ marker alongside the serialized data

How to test?

  1. Create a workflow that uses custom classes
  2. Run the workflow and view it in the observability UI
  3. Verify that custom class instances are displayed with their class names and serialized data
  4. Check that class references are shown as <class:ClassName> in the UI

Why make this change?

Previously, custom class instances couldn't be properly displayed in the observability UI because they weren't registered for deserialization in the o11y context. This change improves the developer experience by providing meaningful representations of custom classes and instances, making it easier to debug and understand workflow execution without requiring class registration.

@changeset-bot
Copy link

changeset-bot bot commented Jan 19, 2026

🦋 Changeset detected

Latest commit: c889dd1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@workflow/web-shared Patch
@workflow/core Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/docs-typecheck Patch
@workflow/next Patch
@workflow/nitro Patch
workflow Patch
@workflow/astro Patch
@workflow/sveltekit Patch
@workflow/world-testing Patch
@workflow/nuxt Patch
@workflow/ai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Jan 19, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 435 0 38 473
❌ 💻 Local Development 396 2 32 430
✅ 📦 Local Production 398 0 32 430
✅ 🐘 Local Postgres 398 0 32 430
✅ 🪟 Windows 43 0 0 43
❌ 🌍 Community Worlds 162 22 0 184
Total 1832 24 134 1990

❌ Failed Tests

💻 Local Development (2 failed)

sveltekit-stable (2 failed):

  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
🌍 Community Worlds (22 failed)

mongodb (1 failed):

  • webhookWorkflow

redis (1 failed):

  • webhookWorkflow

starter (19 failed):

  • addTenWorkflow
  • addTenWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE

turso (1 failed):

  • webhookWorkflow

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 39 0 4
✅ example 39 0 4
✅ express 39 0 4
✅ fastify 39 0 4
✅ hono 39 0 4
✅ nextjs-turbopack 42 0 1
✅ nextjs-webpack 42 0 1
✅ nitro 39 0 4
✅ nuxt 39 0 4
✅ sveltekit 39 0 4
✅ vite 39 0 4
❌ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 39 0 4
✅ express-stable 39 0 4
✅ fastify-stable 39 0 4
✅ hono-stable 39 0 4
✅ nextjs-turbopack-stable 43 0 0
✅ nextjs-webpack-stable 43 0 0
✅ nitro-stable 39 0 4
✅ nuxt-stable 39 0 4
❌ sveltekit-stable 37 2 4
✅ vite-stable 39 0 4
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 39 0 4
✅ express-stable 39 0 4
✅ fastify-stable 39 0 4
✅ hono-stable 39 0 4
✅ nextjs-turbopack-stable 43 0 0
✅ nextjs-webpack-stable 43 0 0
✅ nitro-stable 39 0 4
✅ nuxt-stable 39 0 4
✅ sveltekit-stable 39 0 4
✅ vite-stable 39 0 4
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 39 0 4
✅ express-stable 39 0 4
✅ fastify-stable 39 0 4
✅ hono-stable 39 0 4
✅ nextjs-turbopack-stable 43 0 0
✅ nextjs-webpack-stable 43 0 0
✅ nitro-stable 39 0 4
✅ nuxt-stable 39 0 4
✅ sveltekit-stable 39 0 4
✅ vite-stable 39 0 4
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 43 0 0
❌ 🌍 Community Worlds
App Passed Failed Skipped
✅ mongodb-dev 3 0 0
❌ mongodb 42 1 0
✅ redis-dev 3 0 0
❌ redis 42 1 0
✅ starter-dev 3 0 0
❌ starter 24 19 0
✅ turso-dev 3 0 0
❌ turso 42 1 0

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: failure
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

Copy link
Member Author

TooTallNate commented Jan 19, 2026

@TooTallNate TooTallNate marked this pull request as ready for review January 19, 2026 22:17
Copilot AI review requested due to automatic review settings January 19, 2026 22:17
@vercel
Copy link
Contributor

vercel bot commented Jan 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Jan 20, 2026 9:51am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jan 20, 2026 9:51am
example-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-astro-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-express-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-fastify-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-hono-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-nitro-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-nuxt-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-sveltekit-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-vite-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workflow-docs Ready Ready Preview, Comment Jan 20, 2026 9:51am

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enhances the observability UI to display custom class instances that cannot be fully deserialized because their classes are not registered in the o11y context. The implementation adds opaque markers for both class instances and class references, making it easier to debug workflows that use custom classes.

Changes:

  • Added CLASS_INSTANCE_REF_TYPE marker, ClassInstanceRef interface, and type guards to represent unregistered class instances
  • Implemented helper functions (extractClassName, serializedInstanceToRef, serializedClassToString) to convert serialized class data to display-friendly formats
  • Extended streamPrintRevivers to handle Instance and Class types by converting them to opaque markers
  • Updated UI transformation logic to display class instances inline with a __class__ marker alongside their serialized data

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.

File Description
packages/core/src/observability.ts Adds ClassInstanceRef types, helper functions for class name extraction, and stream print revivers for Instance and Class types
packages/web-shared/src/sidebar/attribute-panel.tsx Duplicates ClassInstanceRef types for client-side use and updates transformValueForDisplay to handle class instance refs inline with class marker
.changeset/forty-tables-lick.md Documents the patch-level changes for both packages

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

'__type' in value &&
value.__type === CLASS_INSTANCE_REF_TYPE &&
'className' in value &&
typeof value.className === 'string'
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type guard isClassInstanceRef validates className but not classId. For consistency with isStreamRef which validates all required fields, and to ensure type safety, the function should also validate that classId exists and is a string.

Suggested change
typeof value.className === 'string'
typeof value.className === 'string' &&
'classId' in value &&
typeof value.classId === 'string'

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +128
const extractClassName = (classId: string): string => {
if (!classId) return 'Unknown';
const parts = classId.split('/');
return parts[parts.length - 1] || classId;
};
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extractClassName function returns 'Unknown' when classId is falsy, but an empty string is falsy in JavaScript. When classId is an empty string, the function would return 'Unknown' without trying to split it. However, if the split produces an empty array or the last part is an empty string, it would fall back to returning the original classId. Consider handling the empty string case explicitly before the split operation to ensure consistent behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +244 to +249
return {
__class__: v.className,
...(typeof v.data === 'object' && v.data !== null
? v.data
: { value: v.data }),
};
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spread operator on line 247 could potentially overwrite the class property if v.data is an object that contains a class key. This would result in the class name information being lost. Consider using a different marker name that's less likely to collide with actual object properties, or ensure the class property cannot be overwritten by spreading v.data after it instead of before.

Suggested change
return {
__class__: v.className,
...(typeof v.data === 'object' && v.data !== null
? v.data
: { value: v.data }),
};
return typeof v.data === 'object' && v.data !== null
? {
...(v.data as Record<string, unknown>),
__class__: v.className,
}
: {
value: v.data,
__class__: v.className,
};

Copilot uses AI. Check for mistakes.
Comment on lines +244 to +248
return {
__class__: v.className,
...(typeof v.data === 'object' && v.data !== null
? v.data
: { value: v.data }),
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The v.data object is spread directly without being transformed recursively. If v.data contains nested StreamRef or ClassInstanceRef objects, they won't be properly handled. The nested data should be recursively transformed before spreading to ensure all special references are properly converted.

Suggested change
return {
__class__: v.className,
...(typeof v.data === 'object' && v.data !== null
? v.data
: { value: v.data }),
const transformedData =
typeof v.data === 'object' && v.data !== null
? transform(v.data)
: transform(v.data);
return {
__class__: v.className,
...(typeof v.data === 'object' && v.data !== null
? (transformedData as Record<string, unknown>)
: { value: transformedData }),

Copilot uses AI. Check for mistakes.
Comment on lines +151 to 157
const serializedClassToString = (value: { classId: string }): string => {
const className = extractClassName(value.classId);
return `<class:${className}>`;
};

/**
* This is an extra reviver for devalue that takes any streams that would be converted,
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serializedClassToString function doesn't validate that the input value has the expected structure. If value.classId is undefined or not a string, extractClassName will handle it, but it would be clearer to add explicit validation to ensure the function is called with the expected shape of data.

Suggested change
const serializedClassToString = (value: { classId: string }): string => {
const className = extractClassName(value.classId);
return `<class:${className}>`;
};
/**
* This is an extra reviver for devalue that takes any streams that would be converted,
* Convert a serialized class reference to a string representation.
* This is used for Class type (the constructor reference itself, not an instance).
*/
const serializedClassToString = (value: unknown): string => {
if (
!value ||
typeof value !== 'object' ||
!('classId' in value) ||
typeof (value as { classId: unknown }).classId !== 'string'
) {
return '<class:Unknown>';
}
const classId = (value as { classId: string }).classId;
const className = extractClassName(classId);
return `<class:${className}>`;
};

Copilot uses AI. Check for mistakes.
Comment on lines +139 to +143
return {
__type: CLASS_INSTANCE_REF_TYPE,
className: extractClassName(value.classId),
classId: value.classId,
data: value.data,
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serializedInstanceToRef function doesn't validate that the input value has the expected structure. While the type annotation suggests the expected shape, adding runtime validation would make the function more robust, especially since it's called from the streamPrintRevivers which receives values of type any.

Suggested change
return {
__type: CLASS_INSTANCE_REF_TYPE,
className: extractClassName(value.classId),
classId: value.classId,
data: value.data,
// Runtime validation because this function is ultimately called with values of type `any`.
const isObject = value !== null && typeof value === 'object';
const classId = isObject && typeof (value as any).classId === 'string'
? (value as any).classId
: '';
const data = isObject ? (value as any).data : value;
return {
__type: CLASS_INSTANCE_REF_TYPE,
className: extractClassName(classId),
classId,
data,

Copilot uses AI. Check for mistakes.
Comment on lines +120 to +154
/**
* Extract the class name from a classId.
* The classId format is typically "path/to/file/ClassName" so we extract the last segment.
*/
const extractClassName = (classId: string): string => {
if (!classId) return 'Unknown';
const parts = classId.split('/');
return parts[parts.length - 1] || classId;
};

/**
* Convert a serialized class instance to a ClassInstanceRef for o11y display.
* This allows viewing custom class instances in the UI without needing
* the class to be registered for deserialization.
*/
const serializedInstanceToRef = (value: {
classId: string;
data: unknown;
}): ClassInstanceRef => {
return {
__type: CLASS_INSTANCE_REF_TYPE,
className: extractClassName(value.classId),
classId: value.classId,
data: value.data,
};
};

/**
* Convert a serialized class reference to a string representation.
* This is used for Class type (the constructor reference itself, not an instance).
*/
const serializedClassToString = (value: { classId: string }): string => {
const className = extractClassName(value.classId);
return `<class:${className}>`;
};
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new helper functions extractClassName, serializedInstanceToRef, and serializedClassToString lack test coverage. Given that other utility functions in the codebase have comprehensive test files (e.g., serialization.test.ts, parse-name.test.ts), consider adding tests to verify the behavior of these functions, especially edge cases like empty classId, classIds with different path separators, and handling of various data types.

Copilot uses AI. Check for mistakes.
'__type' in value &&
value.__type === CLASS_INSTANCE_REF_TYPE &&
'className' in value &&
typeof value.className === 'string'
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type guard isClassInstanceRef validates className but not classId. For consistency with isStreamRef which validates all required fields, and to ensure type safety, the function should also validate that classId exists and is a string. This would prevent runtime errors if a malformed ClassInstanceRef object is encountered.

Suggested change
typeof value.className === 'string'
typeof value.className === 'string' &&
'classId' in value &&
typeof value.classId === 'string'

Copilot uses AI. Check for mistakes.
@TooTallNate TooTallNate force-pushed the 01-19-show_opaque_marker_when_deserializing_custom_classes_and_instances_in_o11y branch from 127cfec to c46327f Compare January 19, 2026 22:26
@TooTallNate TooTallNate force-pushed the 01-09-add_support_for_custom_class_instance_serialization branch from baea85a to 4216347 Compare January 19, 2026 22:26
value: unknown
): { json: string; streamRefs: Map<string, StreamRef> } => {
): {
json: string;
Copy link
Contributor

@vercel vercel bot Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClassInstanceRef transformation allows properties in v.data to overwrite the class marker, and doesn't recursively transform nested ClassInstanceRef objects within v.data

Fix on Vercel

* Used in o11y when a custom class instance is encountered but the class is not
* registered for deserialization.
*/
interface ClassInstanceRef {
Copy link
Contributor

@vercel vercel bot Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type guard isClassInstanceRef doesn't validate classId and data properties required by ClassInstanceRef interface, leading to type unsafety

Fix on Vercel

'__type' in value &&
value.__type === CLASS_INSTANCE_REF_TYPE &&
'className' in value &&
typeof value.className === 'string'
Copy link
Contributor

@vercel vercel bot Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isClassInstanceRef type guard doesn't validate classId and data properties required by the ClassInstanceRef interface

Fix on Vercel

@TooTallNate TooTallNate force-pushed the 01-19-show_opaque_marker_when_deserializing_custom_classes_and_instances_in_o11y branch from a14fe6d to 04b36f5 Compare January 19, 2026 23:14
@github-actions
Copy link
Contributor

github-actions bot commented Jan 19, 2026

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Starter 🥇 Next.js (Turbopack) 0.040s (-1.2%) 1.014s (~) 0.974s 10 1.00x
💻 Local Next.js (Turbopack) 0.042s (+2.7%) 1.019s (~) 0.977s 10 1.06x
💻 Local Nitro 0.043s (-3.3%) 1.007s (~) 0.964s 10 1.09x
💻 Local Express 0.044s (-1.1%) 1.007s (~) 0.963s 10 1.11x
🌐 Redis Next.js (Turbopack) 0.045s (+11.9% 🔺) 1.018s (~) 0.973s 10 1.14x
🌐 Turso Next.js (Turbopack) 0.106s (-3.1%) 1.013s (~) 0.908s 10 2.66x
🌐 MongoDB Next.js (Turbopack) 0.122s (+123.6% 🔺) 1.018s (~) 0.895s 10 3.08x
🐘 Postgres Next.js (Turbopack) 0.176s (-44.7% 🟢) 1.022s (~) 0.845s 10 4.44x
🐘 Postgres Nitro 0.337s (+6.8% 🔺) 1.012s (~) 0.675s 10 8.49x
🐘 Postgres Express 0.338s (+5.0% 🔺) 1.013s (~) 0.675s 10 8.52x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 0.575s (-80.7% 🟢) 1.580s (-58.0% 🟢) 1.005s 10 1.00x
▲ Vercel Express 0.689s (-83.2% 🟢) 1.686s (-66.5% 🟢) 0.997s 10 1.20x
▲ Vercel Next.js (Turbopack) 0.744s (-66.1% 🟢) 1.568s (-45.5% 🟢) 0.824s 10 1.29x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Starter 🥇 Next.js (Turbopack) 1.090s (~) 2.010s (~) 0.920s 10 1.00x
💻 Local Next.js (Turbopack) 1.098s (+0.6%) 2.012s (~) 0.914s 10 1.01x
💻 Local Express 1.112s (~) 2.006s (~) 0.895s 10 1.02x
🌐 Redis Next.js (Turbopack) 1.113s (+0.8%) 2.013s (~) 0.900s 10 1.02x
💻 Local Nitro 1.115s (~) 2.007s (~) 0.892s 10 1.02x
🌐 MongoDB Next.js (Turbopack) 1.261s (-4.0%) 2.016s (~) 0.755s 10 1.16x
🌐 Turso Next.js (Turbopack) 1.305s (~) 2.013s (~) 0.707s 10 1.20x
🐘 Postgres Express 2.185s (+1.0%) 3.015s (~) 0.830s 10 2.00x
🐘 Postgres Nitro 2.194s (+0.7%) 3.015s (~) 0.821s 10 2.01x
🐘 Postgres Next.js (Turbopack) 2.322s (+35.2% 🔺) 2.818s (+33.2% 🔺) 0.496s 10 2.13x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 2.682s (-67.8% 🟢) 3.592s (-59.7% 🟢) 0.910s 10 1.00x
▲ Vercel Express 2.694s (-60.3% 🟢) 3.718s (-51.6% 🟢) 1.024s 10 1.00x
▲ Vercel Nitro 2.712s (-61.9% 🟢) 3.698s (-53.0% 🟢) 0.987s 10 1.01x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Starter 🥇 Next.js (Turbopack) 10.606s (~) 11.012s (~) 0.406s 5 1.00x
💻 Local Next.js (Turbopack) 10.664s (~) 11.014s (~) 0.350s 5 1.01x
🌐 Redis Next.js (Turbopack) 10.708s (~) 11.021s (~) 0.314s 5 1.01x
💻 Local Express 10.787s (~) 11.010s (~) 0.223s 5 1.02x
💻 Local Nitro 10.789s (~) 11.009s (~) 0.220s 5 1.02x
🌐 MongoDB Next.js (Turbopack) 12.119s (-1.1%) 12.829s (-1.5%) 0.710s 5 1.14x
🌐 Turso Next.js (Turbopack) 12.175s (~) 13.020s (~) 0.845s 5 1.15x
🐘 Postgres Next.js (Turbopack) 15.410s (+2.7%) 15.838s (+1.3%) 0.428s 5 1.45x
🐘 Postgres Nitro 20.351s (~) 21.029s (~) 0.678s 5 1.92x
🐘 Postgres Express 20.416s (~) 21.034s (~) 0.618s 5 1.92x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 21.836s (-1.2%) 22.549s (-1.4%) 0.713s 5 1.00x
▲ Vercel Next.js (Turbopack) 22.144s (-0.7%) 22.815s (~) 0.671s 5 1.01x
▲ Vercel Nitro 22.426s (+2.0%) 23.192s (+1.9%) 0.767s 5 1.03x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Starter 🥇 Next.js (Turbopack) 1.355s (~) 2.008s (~) 0.653s 15 1.00x
🌐 Redis Next.js (Turbopack) 1.374s (+1.7%) 2.011s (~) 0.637s 15 1.01x
💻 Local Next.js (Turbopack) 1.392s (~) 2.013s (~) 0.621s 15 1.03x
💻 Local Nitro 1.417s (+0.9%) 2.005s (~) 0.588s 15 1.05x
💻 Local Express 1.420s (+0.6%) 2.007s (~) 0.587s 15 1.05x
🐘 Postgres Next.js (Turbopack) 1.877s (+5.9% 🔺) 2.014s (~) 0.136s 15 1.39x
🌐 MongoDB Next.js (Turbopack) 2.144s (+1.0%) 3.014s (~) 0.870s 10 1.58x
🌐 Turso Next.js (Turbopack) 2.222s (+1.3%) 3.012s (~) 0.790s 10 1.64x
🐘 Postgres Express 2.363s (~) 3.012s (~) 0.649s 10 1.74x
🐘 Postgres Nitro 2.487s (+3.1%) 3.012s (~) 0.525s 10 1.84x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.936s (-8.1% 🟢) 3.789s (-5.6% 🟢) 0.853s 8 1.00x
▲ Vercel Express 3.069s (+6.4% 🔺) 3.925s (+7.8% 🔺) 0.856s 8 1.05x
▲ Vercel Next.js (Turbopack) 3.214s (+7.1% 🔺) 3.982s (+6.0% 🔺) 0.768s 8 1.09x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Next.js (Turbopack) 2.040s (-1.4%) 2.896s (+3.9%) 0.855s 11 1.00x
💻 Local Express 2.208s (~) 3.181s (~) 0.973s 10 1.08x
💻 Local Nitro 2.241s (+1.4%) 3.168s (~) 0.928s 10 1.10x
🌐 Starter Next.js (Turbopack) 2.483s (~) 3.008s (~) 0.525s 10 1.22x
🌐 Redis Next.js (Turbopack) 2.492s (+0.6%) 3.012s (-0.7%) 0.520s 10 1.22x
🐘 Postgres Next.js (Turbopack) 2.550s (-5.2% 🟢) 3.020s (-0.8%) 0.470s 10 1.25x
🐘 Postgres Express 2.876s (-6.9% 🟢) 3.232s (-17.3% 🟢) 0.356s 10 1.41x
🐘 Postgres Nitro 2.955s (~) 3.114s (-7.0% 🟢) 0.159s 10 1.45x
🌐 MongoDB Next.js (Turbopack) 4.650s (-0.6%) 5.185s (~) 0.535s 6 2.28x
🌐 Turso Next.js (Turbopack) 4.851s (+1.7%) 5.180s (~) 0.329s 6 2.38x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.997s (+11.6% 🔺) 4.820s (+16.3% 🔺) 0.823s 7 1.00x
▲ Vercel Express 4.022s (+28.6% 🔺) 4.751s (+23.7% 🔺) 0.729s 7 1.01x
▲ Vercel Next.js (Turbopack) 4.660s (+40.1% 🔺) 5.415s (+38.1% 🔺) 0.755s 6 1.17x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🌐 Starter 🥇 Next.js (Turbopack) 1.359s (+0.5%) 2.007s (~) 0.648s 15 1.00x
🌐 Redis Next.js (Turbopack) 1.367s (~) 2.011s (~) 0.644s 15 1.01x
💻 Local Express 1.406s (-1.4%) 2.006s (~) 0.600s 15 1.03x
💻 Local Next.js (Turbopack) 1.409s (+1.3%) 2.015s (~) 0.605s 15 1.04x
💻 Local Nitro 1.428s (~) 2.005s (~) 0.577s 15 1.05x
🐘 Postgres Nitro 1.557s (-23.4% 🟢) 2.010s (-16.1% 🟢) 0.452s 15 1.15x
🐘 Postgres Next.js (Turbopack) 1.676s (-25.9% 🟢) 2.013s (-25.2% 🟢) 0.337s 15 1.23x
🐘 Postgres Express 1.836s (+3.1%) 2.010s (-3.3%) 0.174s 15 1.35x
🌐 MongoDB Next.js (Turbopack) 2.116s (-0.9%) 3.019s (~) 0.903s 10 1.56x
🌐 Turso Next.js (Turbopack) 2.219s (-1.2%) 3.013s (~) 0.794s 10 1.63x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 2.837s (+2.4%) 3.571s (-2.7%) 0.735s 9 1.00x
▲ Vercel Express 3.139s (+7.3% 🔺) 4.119s (+10.8% 🔺) 0.980s 8 1.11x
▲ Vercel Nitro 3.218s (-5.2% 🟢) 4.497s (+2.9%) 1.279s 7 1.13x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 2.235s (+0.7%) 3.197s (+0.6%) 0.963s 10 1.00x
💻 Local Next.js (Turbopack) 2.307s (+8.1% 🔺) 3.142s (+3.7%) 0.835s 10 1.03x
💻 Local Nitro 2.378s (+3.5%) 3.317s (+2.9%) 0.939s 10 1.06x
🌐 Starter Next.js (Turbopack) 2.459s (~) 3.008s (~) 0.549s 10 1.10x
🐘 Postgres Nitro 2.497s (-18.3% 🟢) 3.017s (-15.7% 🟢) 0.520s 10 1.12x
🌐 Redis Next.js (Turbopack) 2.517s (+1.3%) 3.012s (~) 0.495s 10 1.13x
🐘 Postgres Next.js (Turbopack) 2.583s (-5.0%) 3.029s (-0.8%) 0.447s 10 1.16x
🐘 Postgres Express 2.642s (-8.9% 🟢) 3.018s (-6.5% 🟢) 0.377s 10 1.18x
🌐 MongoDB Next.js (Turbopack) 4.711s (~) 5.181s (~) 0.470s 6 2.11x
🌐 Turso Next.js (Turbopack) 4.778s (+1.6%) 5.183s (-3.1%) 0.405s 6 2.14x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.209s (+2.5%) 3.778s (+0.8%) 0.569s 8 1.00x
▲ Vercel Express 3.254s (~) 3.848s (+2.4%) 0.594s 8 1.01x
▲ Vercel Next.js (Turbopack) 3.454s (+0.9%) 4.032s (~) 0.578s 8 1.08x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🌐 Starter 🥇 Next.js (Turbopack) 0.129s (~) 1.005s (~) 0.000s (NaN%) 1.011s (~) 0.882s 10 1.00x
💻 Local Next.js (Turbopack) 0.141s (-3.4%) 1.003s (~) 0.017s (-0.6%) 1.028s (~) 0.887s 10 1.09x
🌐 Redis Next.js (Turbopack) 0.159s (+11.6% 🔺) 1.005s (~) 0.000s (-100.0% 🟢) 1.015s (~) 0.855s 10 1.23x
💻 Local Express 0.176s (-0.7%) 0.992s (~) 0.015s (-1.9%) 1.022s (~) 0.846s 10 1.36x
💻 Local Nitro 0.182s (+1.7%) 0.992s (~) 0.015s (-0.7%) 1.021s (~) 0.838s 10 1.41x
🌐 Turso Next.js (Turbopack) 0.488s (+6.3% 🔺) 0.965s (-3.0%) 0.000s (+Infinity% 🔺) 1.013s (~) 0.524s 10 3.78x
🌐 MongoDB Next.js (Turbopack) 0.506s (-3.3%) 0.941s (+1.7%) 0.000s (+Infinity% 🔺) 1.015s (~) 0.509s 10 3.92x
🐘 Postgres Next.js (Turbopack) 0.673s (-29.9% 🟢) 1.052s (-1.5%) 0.000s (-100.0% 🟢) 1.118s (-15.1% 🟢) 0.444s 10 5.21x
🐘 Postgres Express 2.313s (+3.0%) 2.728s (-2.7%) 0.000s (-100.0% 🟢) 3.014s (~) 0.700s 10 17.90x
🐘 Postgres Nitro 2.402s (+3.2%) 2.639s (-2.9%) 0.000s (+Infinity% 🔺) 3.014s (~) 0.611s 10 18.59x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 2.826s (~) 3.323s (+6.2% 🔺) 0.378s (-49.5% 🟢) 4.114s (-4.7%) 1.288s 10 1.00x
▲ Vercel Nitro 2.891s (-0.7%) 3.301s (+3.4%) 0.454s (-23.0% 🟢) 4.226s (-0.6%) 1.335s 10 1.02x
▲ Vercel Express 2.914s (+1.5%) 3.295s (+3.9%) 0.396s (-58.0% 🟢) 4.190s (-8.4% 🟢) 1.276s 10 1.03x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Next.js (Turbopack) 6/8
🐘 Postgres Next.js (Turbopack) 5/8
▲ Vercel Nitro 4/8
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 💻 Local 8/8
Next.js (Turbopack) 🌐 Starter 6/8
Nitro 💻 Local 8/8
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Starter: Community world (local development)
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

Copy link
Member

@VaguelySerious VaguelySerious left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM ✨

One styling change:

className="inline-flex flex-col rounded text-[11px] font-mono my-1"
style={{
backgroundColor: colors.body,
border: `1px solid ${colors.header}`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This leaves some weird extra white pixel borders

Suggested change
border: `1px solid ${colors.header}`,

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I tested too early, this seems to already be resolved

@socket-security
Copy link

socket-security bot commented Jan 20, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​types/​color-hash@​2.0.01001009080100
Addednpm/​color-hash@​2.0.210010010080100

View full report

TooTallNate and others added 2 commits January 20, 2026 01:39
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Nathan Rajlich <n@n8.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants